﻿#if UNITY_EDITOR
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System;
using System.Collections.Specialized;
using System.Diagnostics;
using System.IO;
using VRC.Core.BestHTTP;
using VRC.Core.BestHTTP.Authentication;
using VRC.Core.BestHTTP.JSON;
using Debug = UnityEngine.Debug;
using System.Text.RegularExpressions;

namespace VRC.Core
{
    public class ApiFileHelper : MonoBehaviour
    {
        private readonly int kMultipartUploadChunkSize = 10 * 1024 * 1024;
        private readonly int SERVER_PROCESSING_WAIT_TIMEOUT_CHUNK_SIZE = 50 * 1024 * 1024;
        private readonly float SERVER_PROCESSING_WAIT_TIMEOUT_PER_CHUNK_SIZE = 120.0f;
        private readonly float SERVER_PROCESSING_MAX_WAIT_TIMEOUT = 600.0f;
        private readonly float SERVER_PROCESSING_INITIAL_RETRY_TIME = 2.0f;
        private readonly float SERVER_PROCESSING_MAX_RETRY_TIME = 10.0f;

        private static bool EnableDeltaCompression = false;

        private readonly Regex[] kUnityPackageAssetNameFilters = new Regex[]
        {
            new Regex(@"/LightingData\.asset$"),                    // lightmap base asset
            new Regex(@"/Lightmap-.*(\.png|\.exr)$"),               // lightmaps
            new Regex(@"/ReflectionProbe-.*(\.exr|\.png)$"),        // reflection probes
            new Regex(@"/Editor/Data/UnityExtensions/")             // anything that looks like part of the Unity installation
        };

        public delegate void OnFileOpSuccess(ApiFile apiFile, string message);
        public delegate void OnFileOpError(ApiFile apiFile, string error);
        public delegate void OnFileOpProgress(ApiFile apiFile, string status, string subStatus, float pct);
        public delegate bool FileOpCancelQuery(ApiFile apiFile);

        public static ApiFileHelper Instance
        {
            get
            {
                CheckInstance();
                return mInstance;
            }
        }

        private static ApiFileHelper mInstance = null;
        const float kPostWriteDelay = 0.75f;

        public bool DebugEnabled
        {
            get { return true; }
        }

        public enum FileOpResult
        {
            Success,
            Unchanged
        }

        public static void UploadFileAsync(string filename, string existingFileId, string friendlyName,
            OnFileOpSuccess onSuccess, OnFileOpError onError, OnFileOpProgress onProgress, FileOpCancelQuery cancelQuery)
        {
            Instance.StartCoroutine(Instance.UploadFile(filename, existingFileId, friendlyName, onSuccess, onError,
                onProgress, cancelQuery));
        }

        public static string GetMimeTypeFromExtension(string extension)
        {
            if (extension == ".vrcw")
                return "application/x-world";
            if (extension == ".vrca")
                return "application/x-avatar";
            if (extension == ".dll")
                return "application/x-msdownload";
            if (extension == ".unitypackage")
                return "application/gzip";
            if (extension == ".gz")
                return "application/gzip";
            if (extension == ".jpg")
                return "image/jpg";
            if (extension == ".png")
                return "image/png";
            if (extension == ".sig")
                return "application/x-rsync-signature";
            if (extension == ".delta")
                return "application/x-rsync-delta";

            Debug.LogWarning("Unknown file extension for mime-type: " + extension);
            return "application/octet-stream";
        }

        public static bool IsGZipCompressed(string filename)
        {
            return GetMimeTypeFromExtension(Path.GetExtension(filename)) == "application/gzip";
        }

        public IEnumerator UploadFile(string filename, string existingFileId, string friendlyName,
            OnFileOpSuccess onSuccess, OnFileOpError onError, OnFileOpProgress onProgress, FileOpCancelQuery cancelQuery)
        {
            Debug.Log("UploadFile: filename: " + filename + ", file id: " +
                      (!string.IsNullOrEmpty(existingFileId) ? existingFileId : "<new>") + ", name: " + friendlyName);

            // init remote config 
            if (!RemoteConfig.IsInitialized())
            {
                bool done = false;
                RemoteConfig.Init(
                    delegate () { done = true; },
                    delegate () { done = true; }
                );

                while (!done)
                    yield return null;

                if (!RemoteConfig.IsInitialized())
                {
                    Error(onError, null, "Failed to fetch configuration.");
                    yield break;
                }
            }

            // configure delta compression
            {
                EnableDeltaCompression = RemoteConfig.GetBool("sdkEnableDeltaCompression", false);
            }

            // validate input file
            Progress(onProgress, null, "Checking file...");

            if (string.IsNullOrEmpty(filename))
            {
                Error(onError, null, "Upload filename is empty!");
                yield break;
            }

            if (!System.IO.Path.HasExtension(filename))
            {
                Error(onError, null, "Upload filename must have an extension: " + filename);
                yield break;
            }

            string whyNot;
            if (!VRC.Tools.FileCanRead(filename, out whyNot))
            {
                Error(onError, null, "Could not read file to upload!", filename + "\n" + whyNot);
                yield break;
            }

            // get or create ApiFile
            Progress(onProgress, null, string.IsNullOrEmpty(existingFileId) ? "Creating file record..." : "Getting file record...");

            bool wait = true;
            bool wasError = false;
            bool worthRetry = false;
            string errorStr = "";

            if (string.IsNullOrEmpty(friendlyName))
                friendlyName = filename;

            string extension = System.IO.Path.GetExtension(filename);
            string mimeType = GetMimeTypeFromExtension(extension);

            ApiFile apiFile = null;

            System.Action<ApiContainer> fileSuccess = (ApiContainer c) =>
            {
                apiFile = c.Model as ApiFile;
                wait = false;
            };

            System.Action<ApiContainer> fileFailure = (ApiContainer c) =>
            {
                errorStr = c.Error;
                wait = false;

                if (c.Code == 400)
                    worthRetry = true;
            };

            while (true)
            {
                apiFile = null;
                wait = true;
                worthRetry = false;
                errorStr = "";

                if (string.IsNullOrEmpty(existingFileId))
                    ApiFile.Create(friendlyName, mimeType, extension, fileSuccess, fileFailure);
                else
                    API.Fetch<ApiFile>(existingFileId, fileSuccess, fileFailure);

                while (wait)
                {
                    if (apiFile != null && CheckCancelled(cancelQuery, onError, apiFile))
                        yield break;

                    yield return null;
                }

                if (!string.IsNullOrEmpty(errorStr))
                {
                    if (errorStr.Contains("File not found"))
                    {
                        Debug.LogError("Couldn't find file record: " + existingFileId + ", creating new file record");

                        existingFileId = "";
                        continue;
                    }

                    string msg = string.IsNullOrEmpty(existingFileId) ? "Failed to create file record." : "Failed to get file record.";
                    Error(onError, null, msg, errorStr);

                    if (!worthRetry)
                        yield break;
                }

                if (!worthRetry)
                    break;
                else
                    yield return new WaitForSecondsRealtime(kPostWriteDelay);
            }

            if (apiFile == null)
                yield break;

            LogApiFileStatus(apiFile, false);

            while (apiFile.HasQueuedOperation(EnableDeltaCompression))
            {
                wait = true;

                apiFile.DeleteLatestVersion((c) => wait = false, (c) => wait = false);

                while (wait)
                {
                    if (apiFile != null && CheckCancelled(cancelQuery, onError, apiFile))
                        yield break;

                    yield return null;
                }
            }

            // delay to let write get through servers
            yield return new WaitForSecondsRealtime(kPostWriteDelay);

            LogApiFileStatus(apiFile, false);

            // check for server side errors from last upload
            if (apiFile.IsInErrorState())
            {
                Debug.LogWarning("ApiFile: " + apiFile.id + ": server failed to process last uploaded, deleting failed version");

                while (true)
                {
                    // delete previous failed version
                    Progress(onProgress, apiFile, "Preparing file for upload...", "Cleaning up previous version");

                    wait = true;
                    errorStr = "";
                    worthRetry = false;

                    apiFile.DeleteLatestVersion(fileSuccess, fileFailure);

                    while (wait)
                    {
                        if (CheckCancelled(cancelQuery, onError, null))
                        {
                            yield break;
                        }

                        yield return null;
                    }

                    if (!string.IsNullOrEmpty(errorStr))
                    {
                        Error(onError, apiFile, "Failed to delete previous failed version!", errorStr);
                        if (!worthRetry)
                        {
                            CleanupTempFiles(apiFile.id);
                            yield break;
                        }
                    }

                    if (worthRetry)
                        yield return new WaitForSecondsRealtime(kPostWriteDelay);
                    else
                        break;
                }
            }

            // delay to let write get through servers
            yield return new WaitForSecondsRealtime(kPostWriteDelay);

            LogApiFileStatus(apiFile, false);

            // verify previous file op is complete
            if (apiFile.HasQueuedOperation(EnableDeltaCompression))
            {
                Error(onError, apiFile, "A previous upload is still being processed. Please try again later.");
                yield break;
            }

            // prepare file for upload
            Progress(onProgress, apiFile, "Preparing file for upload...", "Optimizing file");

            string uploadFilename = VRC.Tools.GetTempFileName(Path.GetExtension(filename), out errorStr, apiFile.id);
            if (string.IsNullOrEmpty(uploadFilename))
            {
                Error(onError, apiFile, "Failed to optimize file for upload.", "Failed to create temp file: \n" + errorStr);
                yield break;
            }

            wasError = false;
            yield return StartCoroutine(CreateOptimizedFileInternal(filename, uploadFilename,
                delegate (FileOpResult res)
                {
                    if (res == FileOpResult.Unchanged)
                        uploadFilename = filename;
                },
                delegate (string error)
                {
                    Error(onError, apiFile, "Failed to optimize file for upload.", error);
                    CleanupTempFiles(apiFile.id);
                    wasError = true;
                })
            );

            if (wasError)
                yield break;

            LogApiFileStatus(apiFile, false);

            // generate md5 and check if file has changed
            Progress(onProgress, apiFile, "Preparing file for upload...", "Generating file hash");

            string fileMD5Base64 = "";
            wait = true;
            errorStr = "";
            VRC.Tools.FileMD5(uploadFilename,
                delegate (byte[] md5Bytes)
                {
                    fileMD5Base64 = Convert.ToBase64String(md5Bytes);
                    wait = false;
                },
                delegate (string error)
                {
                    errorStr = uploadFilename + "\n" + error;
                    wait = false;
                }
            );

            while (wait)
            {
                if (CheckCancelled(cancelQuery, onError, apiFile))
                {
                    CleanupTempFiles(apiFile.id);
                    yield break;
                }
                yield return null;
            }

            if (!string.IsNullOrEmpty(errorStr))
            {
                Error(onError, apiFile, "Failed to generate MD5 hash for upload file.", errorStr);
                CleanupTempFiles(apiFile.id);
                yield break;
            }

            LogApiFileStatus(apiFile, false);

            // check if file has been changed
            Progress(onProgress, apiFile, "Preparing file for upload...", "Checking for changes");

            bool isPreviousUploadRetry = false;
            if (apiFile.HasExistingOrPendingVersion())
            {
                // uploading the same file?
                if (string.Compare(fileMD5Base64, apiFile.GetFileMD5(apiFile.GetLatestVersionNumber())) == 0)
                {
                    // the previous operation completed successfully?
                    if (!apiFile.IsWaitingForUpload())
                    {
                        Success(onSuccess, apiFile, "The file to upload is unchanged.");
                        CleanupTempFiles(apiFile.id);
                        yield break;
                    }
                    else
                    {
                        isPreviousUploadRetry = true;

                        Debug.Log("Retrying previous upload");
                    }
                }
                else
                {
                    // the file has been modified
                    if (apiFile.IsWaitingForUpload())
                    {
                        // previous upload failed, and the file is changed
                        while (true)
                        {
                            // delete previous failed version
                            Progress(onProgress, apiFile, "Preparing file for upload...", "Cleaning up previous version");

                            wait = true;
                            worthRetry = false;
                            errorStr = "";

                            apiFile.DeleteLatestVersion(fileSuccess, fileFailure);

                            while (wait)
                            {
                                if (CheckCancelled(cancelQuery, onError, apiFile))
                                {
                                    yield break;
                                }
                                yield return null;
                            }

                            if (!string.IsNullOrEmpty(errorStr))
                            {
                                Error(onError, apiFile, "Failed to delete previous incomplete version!", errorStr);
                                if (!worthRetry)
                                {
                                    CleanupTempFiles(apiFile.id);
                                    yield break;
                                }
                            }

                            // delay to let write get through servers
                            yield return new WaitForSecondsRealtime(kPostWriteDelay);

                            if (!worthRetry)
                                break;
                        }
                    }
                }
            }

            LogApiFileStatus(apiFile, false);

            // generate signature for new file

            Progress(onProgress, apiFile, "Preparing file for upload...", "Generating signature");

            string signatureFilename = VRC.Tools.GetTempFileName(".sig", out errorStr, apiFile.id);
            if (string.IsNullOrEmpty(signatureFilename))
            {
                Error(onError, apiFile, "Failed to generate file signature!", "Failed to create temp file: \n" + errorStr);
                CleanupTempFiles(apiFile.id);
                yield break;
            }

            wasError = false;
            yield return StartCoroutine(CreateFileSignatureInternal(uploadFilename, signatureFilename,
                delegate ()
                {
                    // success!
                },
                delegate (string error)
                {
                    Error(onError, apiFile, "Failed to generate file signature!", error);
                    CleanupTempFiles(apiFile.id);
                    wasError = true;
                })
            );

            if (wasError)
                yield break;

            LogApiFileStatus(apiFile, false);

            // generate signature md5 and file size
            Progress(onProgress, apiFile, "Preparing file for upload...", "Generating signature hash");

            string sigMD5Base64 = "";
            wait = true;
            errorStr = "";
            VRC.Tools.FileMD5(signatureFilename,
                delegate (byte[] md5Bytes)
                {
                    sigMD5Base64 = Convert.ToBase64String(md5Bytes);
                    wait = false;
                },
                delegate (string error)
                {
                    errorStr = signatureFilename + "\n" + error;
                    wait = false;
                }
            );

            while (wait)
            {
                if (CheckCancelled(cancelQuery, onError, apiFile))
                {
                    CleanupTempFiles(apiFile.id);
                    yield break;
                }
                yield return null;
            }

            if (!string.IsNullOrEmpty(errorStr))
            {
                Error(onError, apiFile, "Failed to generate MD5 hash for signature file.", errorStr);
                CleanupTempFiles(apiFile.id);
                yield break;
            }

            long sigFileSize = 0;
            if (!VRC.Tools.GetFileSize(signatureFilename, out sigFileSize, out errorStr))
            {
                Error(onError, apiFile, "Failed to generate file signature!", "Couldn't get file size:\n" + errorStr);
                CleanupTempFiles(apiFile.id);
                yield break;
            }

            LogApiFileStatus(apiFile, false);

            // download previous version signature (if exists)
            string existingFileSignaturePath = null;
            if (EnableDeltaCompression && apiFile.HasExistingVersion())
            {
                Progress(onProgress, apiFile, "Preparing file for upload...", "Downloading previous version signature");

                wait = true;
                errorStr = "";
                apiFile.DownloadSignature(
                    delegate (byte[] data)
                    {
                        // save to temp file
                        existingFileSignaturePath = VRC.Tools.GetTempFileName(".sig", out errorStr, apiFile.id);
                        if (string.IsNullOrEmpty(existingFileSignaturePath))
                        {
                            errorStr = "Failed to create temp file: \n" + errorStr;
                            wait = false;
                        }
                        else
                        {
                            try
                            {
                                File.WriteAllBytes(existingFileSignaturePath, data);
                            }
                            catch (Exception e)
                            {
                                existingFileSignaturePath = null;
                                errorStr = "Failed to write signature temp file:\n" + e.Message;
                            }
                            wait = false;
                        }
                    },
                    delegate (string error)
                    {
                        errorStr = error;
                        wait = false;
                    },
                    delegate (int downloaded, int length)
                    {
                        Progress(onProgress, apiFile, "Preparing file for upload...", "Downloading previous version signature", Tools.DivideSafe(downloaded, length));
                    }
                );

                while (wait)
                {
                    if (CheckCancelled(cancelQuery, onError, apiFile))
                    {
                        CleanupTempFiles(apiFile.id);
                        yield break;
                    }
                    yield return null;
                }

                if (!string.IsNullOrEmpty(errorStr))
                {
                    Error(onError, apiFile, "Failed to download previous file version signature.", errorStr);
                    CleanupTempFiles(apiFile.id);
                    yield break;
                }
            }

            LogApiFileStatus(apiFile, false);

            // create delta if needed
            string deltaFilename = null;

            if (EnableDeltaCompression && !string.IsNullOrEmpty(existingFileSignaturePath))
            {
                Progress(onProgress, apiFile, "Preparing file for upload...", "Creating file delta");

                deltaFilename = VRC.Tools.GetTempFileName(".delta", out errorStr, apiFile.id);
                if (string.IsNullOrEmpty(deltaFilename))
                {
                    Error(onError, apiFile, "Failed to create file delta for upload.", "Failed to create temp file: \n" + errorStr);
                    CleanupTempFiles(apiFile.id);
                    yield break;
                }

                wasError = false;
                yield return StartCoroutine(CreateFileDeltaInternal(uploadFilename, existingFileSignaturePath, deltaFilename,
                    delegate ()
                    {
                        // success!
                    },
                    delegate (string error)
                    {
                        Error(onError, apiFile, "Failed to create file delta for upload.", error);
                        CleanupTempFiles(apiFile.id);
                        wasError = true;
                    })
                );

                if (wasError)
                    yield break;
            }

            // upload smaller of delta and new file
            long fullFizeSize = 0;
            long deltaFileSize = 0;
            if (!VRC.Tools.GetFileSize(uploadFilename, out fullFizeSize, out errorStr) ||
                (!string.IsNullOrEmpty(deltaFilename) && !VRC.Tools.GetFileSize(deltaFilename, out deltaFileSize, out errorStr)))
            {
                Error(onError, apiFile, "Failed to create file delta for upload.", "Couldn't get file size: " + errorStr);
                CleanupTempFiles(apiFile.id);
                yield break;
            }

            bool uploadDeltaFile = EnableDeltaCompression && deltaFileSize > 0 && deltaFileSize < fullFizeSize;
            if (EnableDeltaCompression)
                Debug.Log("Delta size " + deltaFileSize + " (" + ((float)deltaFileSize / (float)fullFizeSize) + " %), full file size " + fullFizeSize + ", uploading " + (uploadDeltaFile ? " DELTA" : " FULL FILE"));
            else
                Debug.Log("Delta compression disabled, uploading FULL FILE, size " + fullFizeSize);

            LogApiFileStatus(apiFile, uploadDeltaFile);

            string deltaMD5Base64 = "";
            if (uploadDeltaFile)
            {
                Progress(onProgress, apiFile, "Preparing file for upload...", "Generating file delta hash");

                wait = true;
                errorStr = "";
                VRC.Tools.FileMD5(deltaFilename,
                    delegate (byte[] md5Bytes)
                    {
                        deltaMD5Base64 = Convert.ToBase64String(md5Bytes);
                        wait = false;
                    },
                    delegate (string error)
                    {
                        errorStr = error;
                        wait = false;
                    }
                );

                while (wait)
                {
                    if (CheckCancelled(cancelQuery, onError, apiFile))
                    {
                        CleanupTempFiles(apiFile.id);
                        yield break;
                    }
                    yield return null;
                }

                if (!string.IsNullOrEmpty(errorStr))
                {
                    Error(onError, apiFile, "Failed to generate file delta hash.", errorStr);
                    CleanupTempFiles(apiFile.id);
                    yield break;
                }
            }

            // validate existing pending version info, if this is a retry
            bool versionAlreadyExists = false;

            LogApiFileStatus(apiFile, uploadDeltaFile);

            if (isPreviousUploadRetry)
            {
                bool isValid = true;

                ApiFile.Version v = apiFile.GetVersion(apiFile.GetLatestVersionNumber());
                if (v != null)
                {
                    if (uploadDeltaFile)
                    {
                        isValid = deltaFileSize == v.delta.sizeInBytes &&
                            deltaMD5Base64.CompareTo(v.delta.md5) == 0 &&
                            sigFileSize == v.signature.sizeInBytes &&
                            sigMD5Base64.CompareTo(v.signature.md5) == 0;
                    }
                    else
                    {
                        isValid = fullFizeSize == v.file.sizeInBytes &&
                            fileMD5Base64.CompareTo(v.file.md5) == 0 &&
                            sigFileSize == v.signature.sizeInBytes &&
                            sigMD5Base64.CompareTo(v.signature.md5) == 0;
                    }
                }
                else
                {
                    isValid = false;
                }

                if (isValid)
                {
                    versionAlreadyExists = true;

                    Debug.Log("Using existing version record");
                }
                else
                {
                    // delete previous invalid version
                    Progress(onProgress, apiFile, "Preparing file for upload...", "Cleaning up previous version");

                    while (true)
                    {
                        wait = true;
                        errorStr = "";
                        worthRetry = false;

                        apiFile.DeleteLatestVersion(fileSuccess, fileFailure);

                        while (wait)
                        {
                            if (CheckCancelled(cancelQuery, onError, null))
                            {
                                yield break;
                            }
                            yield return null;
                        }

                        if (!string.IsNullOrEmpty(errorStr))
                        {
                            Error(onError, apiFile, "Failed to delete previous incomplete version!", errorStr);
                            if (!worthRetry)
                            {
                                CleanupTempFiles(apiFile.id);
                                yield break;
                            }
                        }

                        // delay to let write get through servers
                        yield return new WaitForSecondsRealtime(kPostWriteDelay);

                        if (!worthRetry)
                            break;
                    }
                }
            }

            LogApiFileStatus(apiFile, uploadDeltaFile);

            // create new version of file
            if (!versionAlreadyExists)
            {
                while (true)
                {
                    Progress(onProgress, apiFile, "Creating file version record...");

                    wait = true;
                    errorStr = "";
                    worthRetry = false;

                    if (uploadDeltaFile)
                        // delta file
                        apiFile.CreateNewVersion(ApiFile.Version.FileType.Delta, deltaMD5Base64, deltaFileSize, sigMD5Base64, sigFileSize, fileSuccess, fileFailure);
                    else
                        // full file
                        apiFile.CreateNewVersion(ApiFile.Version.FileType.Full, fileMD5Base64, fullFizeSize, sigMD5Base64, sigFileSize, fileSuccess, fileFailure);

                    while (wait)
                    {
                        if (CheckCancelled(cancelQuery, onError, apiFile))
                        {
                            CleanupTempFiles(apiFile.id);
                            yield break;
                        }

                        yield return null;
                    }

                    if (!string.IsNullOrEmpty(errorStr))
                    {
                        Error(onError, apiFile, "Failed to create file version record.", errorStr);
                        if (!worthRetry)
                        {
                            CleanupTempFiles(apiFile.id);
                            yield break;
                        }
                    }

                    // delay to let write get through servers
                    yield return new WaitForSecondsRealtime(kPostWriteDelay);

                    if (!worthRetry)
                        break;
                }
            }

            // upload components

            LogApiFileStatus(apiFile, uploadDeltaFile);

            // upload delta
            if (uploadDeltaFile)
            {
                if (apiFile.GetLatestVersion().delta.status == ApiFile.Status.Waiting)
                {
                    Progress(onProgress, apiFile, "Uploading file delta...");

                    wasError = false;
                    yield return StartCoroutine(UploadFileComponentInternal(apiFile,
                        ApiFile.Version.FileDescriptor.Type.delta, deltaFilename, deltaMD5Base64, deltaFileSize,
                        delegate (ApiFile file)
                        {
                            Debug.Log("Successfully uploaded file delta.");
                            apiFile = file;
                        },
                        delegate (string error)
                        {
                            Error(onError, apiFile, "Failed to upload file delta.", error);
                            CleanupTempFiles(apiFile.id);
                            wasError = true;
                        },
                        delegate (long downloaded, long length)
                        {
                            Progress(onProgress, apiFile, "Uploading file delta...", "", Tools.DivideSafe(downloaded, length));
                        },
                        cancelQuery)
                    );

                    if (wasError)
                        yield break;
                }
            }
            // upload file
            else
            {
                if (apiFile.GetLatestVersion().file.status == ApiFile.Status.Waiting)
                {
                    Progress(onProgress, apiFile, "Uploading file...");

                    wasError = false;
                    yield return StartCoroutine(UploadFileComponentInternal(apiFile,
                        ApiFile.Version.FileDescriptor.Type.file, uploadFilename, fileMD5Base64, fullFizeSize,
                        delegate (ApiFile file)
                        {
                            Debug.Log("Successfully uploaded file.");
                            apiFile = file;
                        },
                        delegate (string error)
                        {
                            Error(onError, apiFile, "Failed to upload file.", error);
                            CleanupTempFiles(apiFile.id);
                            wasError = true;
                        },
                        delegate (long downloaded, long length)
                        {
                            Progress(onProgress, apiFile, "Uploading file...", "", Tools.DivideSafe(downloaded, length));
                        },
                        cancelQuery)
                    );

                    if (wasError)
                        yield break;
                }
            }

            LogApiFileStatus(apiFile, uploadDeltaFile);

            // upload signature
            if (apiFile.GetLatestVersion().signature.status == ApiFile.Status.Waiting)
            {
                Progress(onProgress, apiFile, "Uploading file signature...");

                wasError = false;
                yield return StartCoroutine(UploadFileComponentInternal(apiFile,
                    ApiFile.Version.FileDescriptor.Type.signature, signatureFilename, sigMD5Base64, sigFileSize,
                    delegate (ApiFile file)
                    {
                        Debug.Log("Successfully uploaded file signature.");
                        apiFile = file;
                    },
                    delegate (string error)
                    {
                        Error(onError, apiFile, "Failed to upload file signature.", error);
                        CleanupTempFiles(apiFile.id);
                        wasError = true;
                    },
                    delegate (long downloaded, long length)
                    {
                        Progress(onProgress, apiFile, "Uploading file signature...", "", Tools.DivideSafe(downloaded, length));
                    },
                    cancelQuery)
                );

                if (wasError)
                    yield break;
            }

            LogApiFileStatus(apiFile, uploadDeltaFile);

            // Validate file records queued or complete
            Progress(onProgress, apiFile, "Validating upload...");

            bool isUploadComplete = (uploadDeltaFile
                ? apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), ApiFile.Version.FileDescriptor.Type.delta).status == ApiFile.Status.Complete
                : apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), ApiFile.Version.FileDescriptor.Type.file).status == ApiFile.Status.Complete);
            isUploadComplete = isUploadComplete &&
                               apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), ApiFile.Version.FileDescriptor.Type.signature).status == ApiFile.Status.Complete;

            if (!isUploadComplete)
            {
                Error(onError, apiFile, "Failed to upload file.", "Record status is not 'complete'");
                CleanupTempFiles(apiFile.id);
                yield break;
            }

            bool isServerOpQueuedOrComplete = (uploadDeltaFile
                ? apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), ApiFile.Version.FileDescriptor.Type.file).status != ApiFile.Status.Waiting
                : apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), ApiFile.Version.FileDescriptor.Type.delta).status != ApiFile.Status.Waiting);

            if (!isServerOpQueuedOrComplete)
            {
                Error(onError, apiFile, "Failed to upload file.", "Record is still in 'waiting' status");
                CleanupTempFiles(apiFile.id);
                yield break;
            }

            LogApiFileStatus(apiFile, uploadDeltaFile);

            // wait for server processing to complete
            Progress(onProgress, apiFile, "Processing upload...");
            float checkDelay = SERVER_PROCESSING_INITIAL_RETRY_TIME;
            float maxDelay = SERVER_PROCESSING_MAX_RETRY_TIME;
            float timeout = GetServerProcessingWaitTimeoutForDataSize(apiFile.GetLatestVersion().file.sizeInBytes);
            double initialStartTime = Time.realtimeSinceStartup;
            double startTime = initialStartTime;
            while (apiFile.HasQueuedOperation(uploadDeltaFile))
            {
                // wait before polling again
                Progress(onProgress, apiFile, "Processing upload...", "Checking status in " + Mathf.CeilToInt(checkDelay) + " seconds");

                while (Time.realtimeSinceStartup - startTime < checkDelay)
                {
                    if (CheckCancelled(cancelQuery, onError, apiFile))
                    {
                        CleanupTempFiles(apiFile.id);
                        yield break;
                    }

                    if (Time.realtimeSinceStartup - initialStartTime > timeout)
                    {
                        LogApiFileStatus(apiFile, uploadDeltaFile);

                        Error(onError, apiFile, "Timed out waiting for upload processing to complete.");
                        CleanupTempFiles(apiFile.id);
                        yield break;
                    }

                    yield return null;
                }

                while (true)
                {
                    // check status
                    Progress(onProgress, apiFile, "Processing upload...", "Checking status...");

                    wait = true;
                    worthRetry = false;
                    errorStr = "";
                    API.Fetch<ApiFile>(apiFile.id, fileSuccess, fileFailure);

                    while (wait)
                    {
                        if (CheckCancelled(cancelQuery, onError, apiFile))
                        {
                            CleanupTempFiles(apiFile.id);
                            yield break;
                        }

                        yield return null;
                    }

                    if (!string.IsNullOrEmpty(errorStr))
                    {
                        Error(onError, apiFile, "Checking upload status failed.", errorStr);
                        if (!worthRetry)
                        {
                            CleanupTempFiles(apiFile.id);
                            yield break;
                        }
                    }

                    if (!worthRetry)
                        break;
                }

                checkDelay = Mathf.Min(checkDelay * 2, maxDelay);
                startTime = Time.realtimeSinceStartup;
            }

            // cleanup and wait for it to finish
            yield return StartCoroutine(CleanupTempFilesInternal(apiFile.id));

            Success(onSuccess, apiFile, "Upload complete!");
        }

        private static void LogApiFileStatus(ApiFile apiFile, bool checkDelta)
        {
            if (apiFile == null || !apiFile.IsInitialized)
            {
                Debug.LogFormat("<color=yellow>apiFile not initialized</color>");
            }
            else if (apiFile.IsInErrorState())
            {
                Debug.LogFormat("<color=yellow>ApiFile {0} is in an error state.</color>", apiFile.name);
            }
            else
                Debug.LogFormat("<color=yellow>Processing {3}: {0}, {1}, {2}</color>",
                    apiFile.IsWaitingForUpload() ? "waiting for upload" : "upload complete",
                    apiFile.HasExistingOrPendingVersion() ? "has existing or pending version" : "no previous version",
                    apiFile.IsLatestVersionQueued(checkDelta) ? "latest version queued" : "latest version not queued",
                    apiFile.name);

            if (apiFile != null && apiFile.IsInitialized)
            {
                var apiFields = apiFile.ExtractApiFields();
                if (apiFields != null)
                    Debug.LogFormat("<color=yellow>{0}</color>", VRC.Tools.JsonEncode(apiFields));
            }
        }

        public IEnumerator CreateOptimizedFileInternal(string filename, string outputFilename, Action<FileOpResult> onSuccess, Action<string> onError)
        {
            Debug.Log("CreateOptimizedFile: " + filename + " => " + outputFilename);

            // assume it's a .gz, or a .unitypackage
            // else nothing to do

#if !UNITY_ANDROID

            if (!IsGZipCompressed(filename))
            {
                Debug.Log("CreateOptimizedFile: (not gzip compressed, done)");
                // nothing to do
                if (onSuccess != null)
                    onSuccess(FileOpResult.Unchanged);
                yield break;
            }

            bool isUnityPackage = string.Compare(Path.GetExtension(filename), ".unitypackage", true) == 0;

            yield return null;

            // open file
            const int kGzipBufferSize = 256 * 1024;
            Stream inStream = null;
            try
            {
                inStream = new DotZLib.GZipStream(filename, kGzipBufferSize);
            }
            catch (Exception e)
            {
                if (onError != null)
                    onError("Couldn't read file: " + filename + "\n" + e.Message);
                yield break;
            }

            yield return null;

            // create output
            DotZLib.GZipStream outStream = null;
            try
            {
                outStream = new DotZLib.GZipStream(outputFilename, DotZLib.CompressLevel.Best, true, kGzipBufferSize);    // this lib supports rsyncable output
            }
            catch (Exception e)
            {
                if (inStream != null)
                    inStream.Close();
                if (onError != null)
                    onError("Couldn't create output file: " + outputFilename + "\n" + e.Message);
                yield break;
            }

            yield return null;

            // copy / filter file
            if (isUnityPackage)
            {
                try
                {
                    // discard files in the package we don't need

                    // scan package and make list of asset guids we don't want
                    List<string> assetGuidsToStrip = new List<string>();
                    {
                        byte[] filenameBuf = new byte[4096];
                        ICSharpCode.SharpZipLib.Tar.TarInputStream tarInputStream = new ICSharpCode.SharpZipLib.Tar.TarInputStream(inStream);
                        ICSharpCode.SharpZipLib.Tar.TarEntry tarEntry = tarInputStream.GetNextEntry();
                        while (tarEntry != null)
                        {
                            if (tarEntry.Size > 0 && tarEntry.Name.EndsWith("/pathname", StringComparison.OrdinalIgnoreCase))
                            {
                                int bytesRead = tarInputStream.Read(filenameBuf, 0, (int)tarEntry.Size);
                                if (bytesRead > 0)
                                {
                                    string assetFilename = System.Text.ASCIIEncoding.ASCII.GetString(filenameBuf, 0, bytesRead);
                                    if (kUnityPackageAssetNameFilters.Any(r => r.IsMatch(assetFilename)))
                                    {
                                        string assetGuid = assetFilename.Substring(0, assetFilename.IndexOf('/'));
                                        // Debug.Log("-- stripped file from package: " + assetGuid + " - " + assetFilename);
                                        assetGuidsToStrip.Add(assetGuid);
                                    }
                                }
                            }

                            tarEntry = tarInputStream.GetNextEntry();
                        }

                        tarInputStream.Close();
                    }

                    // rescan input .tar and copy only entries we want to the output
                    {
                        inStream.Close();
                        inStream = new DotZLib.GZipStream(filename, kGzipBufferSize);

                        ICSharpCode.SharpZipLib.Tar.TarOutputStream tarOutputStream = new ICSharpCode.SharpZipLib.Tar.TarOutputStream(outStream);

                        ICSharpCode.SharpZipLib.Tar.TarInputStream tarInputStream = new ICSharpCode.SharpZipLib.Tar.TarInputStream(inStream);
                        ICSharpCode.SharpZipLib.Tar.TarEntry tarEntry = tarInputStream.GetNextEntry();
                        while (tarEntry != null)
                        {
                            string assetGuid = tarEntry.Name.Substring(0, tarEntry.Name.IndexOf('/'));
                            bool strip = assetGuidsToStrip.Any(s => string.Compare(s, assetGuid) == 0);
                            if (!strip)
                            {
                                tarOutputStream.PutNextEntry(tarEntry);
                                tarInputStream.CopyEntryContents(tarOutputStream);
                                tarOutputStream.CloseEntry();
                            }

                            tarEntry = tarInputStream.GetNextEntry();
                        }

                        tarInputStream.Close();
                        tarOutputStream.Close();
                    }
                }
                catch (Exception e)
                {
                    if (inStream != null)
                        inStream.Close();
                    if (outStream != null)
                        outStream.Close();
                    if (onError != null)
                        onError("Failed to strip and recompress file." + "\n" + e.Message);
                    yield break;
                }
            }
            else
            {
                // not a unitypackage 

                // straight stream copy
                try
                {
                    const int bufSize = 256 * 1024;
                    byte[] buf = new byte[bufSize];
                    ICSharpCode.SharpZipLib.Core.StreamUtils.Copy(inStream, outStream, buf);
                }
                catch (Exception e)
                {
                    if (inStream != null)
                        inStream.Close();
                    if (outStream != null)
                        outStream.Close();
                    if (onError != null)
                        onError("Failed to recompress file." + "\n" + e.Message);
                    yield break;
                }
            }

            yield return null;

            if (inStream != null)
                inStream.Close();
            inStream = null;
            if (outStream != null)
                outStream.Close();
            outStream = null;
            yield return null;

            if (onSuccess != null)
                onSuccess(FileOpResult.Success);
#else
            yield return null;
            //if (onError != null)
            //    onError("Not supported on ANDROID platform.");

            Debug.Log("CreateOptimizedFile: Android unsupported");
            if (onSuccess != null)
                onSuccess(FileOpResult.Unchanged);
            yield break;
#endif
        }

        public IEnumerator CreateFileSignatureInternal(string filename, string outputSignatureFilename, Action onSuccess, Action<string> onError)
        {
            Debug.Log("CreateFileSignature: " + filename + " => " + outputSignatureFilename);

            yield return null;

            Stream inStream = null;
            FileStream outStream = null;
            byte[] buf = new byte[64 * 1024];
            IAsyncResult asyncRead = null;
            IAsyncResult asyncWrite = null;

            try
            {
                inStream = librsync.net.Librsync.ComputeSignature(File.OpenRead(filename));
            }
            catch (Exception e)
            {
                if (onError != null)
                    onError("Couldn't open input file: " + e.Message);
                yield break;
            }

            try
            {
                outStream = File.Open(outputSignatureFilename, FileMode.Create, FileAccess.Write);
            }
            catch (Exception e)
            {
                if (onError != null)
                    onError("Couldn't create output file: " + e.Message);
                yield break;
            }

            while (true)
            {
                try
                {
                    asyncRead = inStream.BeginRead(buf, 0, buf.Length, null, null);
                }
                catch (Exception e)
                {
                    if (onError != null)
                        onError("Couldn't read file: " + e.Message);
                    yield break;
                }

                while (!asyncRead.IsCompleted)
                    yield return null;

                int read = 0;
                try
                {
                    read = inStream.EndRead(asyncRead);
                }
                catch (Exception e)
                {
                    if (onError != null)
                        onError("Couldn't read file: " + e.Message);
                    yield break;
                }

                if (read <= 0)
                    break;

                try
                {
                    asyncWrite = outStream.BeginWrite(buf, 0, read, null, null);
                }
                catch (Exception e)
                {
                    if (onError != null)
                        onError("Couldn't write file: " + e.Message);
                    yield break;
                }

                while (!asyncWrite.IsCompleted)
                    yield return null;

                try
                {
                    outStream.EndWrite(asyncWrite);
                }
                catch (Exception e)
                {
                    if (onError != null)
                        onError("Couldn't write file: " + e.Message);
                    yield break;
                }
            }

            inStream.Close();
            outStream.Close();

            yield return null;

            if (onSuccess != null)
                onSuccess();
        }

        public IEnumerator CreateFileDeltaInternal(string newFilename, string existingFileSignaturePath, string outputDeltaFilename, Action onSuccess, Action<string> onError)
        {
            Debug.Log("CreateFileDelta: " + newFilename + " (delta) " + existingFileSignaturePath + " => " + outputDeltaFilename);

            yield return null;

            Stream inStream = null;
            FileStream outStream = null;
            byte[] buf = new byte[64 * 1024];
            IAsyncResult asyncRead = null;
            IAsyncResult asyncWrite = null;

            try
            {
                inStream = librsync.net.Librsync.ComputeDelta(File.OpenRead(existingFileSignaturePath), File.OpenRead(newFilename));
            }
            catch (Exception e)
            {
                if (onError != null)
                    onError("Couldn't open input file: " + e.Message);
                yield break;
            }

            try
            {
                outStream = File.Open(outputDeltaFilename, FileMode.Create, FileAccess.Write);
            }
            catch (Exception e)
            {
                if (onError != null)
                    onError("Couldn't create output file: " + e.Message);
                yield break;
            }

            while (true)
            {
                try
                {
                    asyncRead = inStream.BeginRead(buf, 0, buf.Length, null, null);
                }
                catch (Exception e)
                {
                    if (onError != null)
                        onError("Couldn't read file: " + e.Message);
                    yield break;
                }

                while (!asyncRead.IsCompleted)
                    yield return null;

                int read = 0;
                try
                {
                    read = inStream.EndRead(asyncRead);
                }
                catch (Exception e)
                {
                    if (onError != null)
                        onError("Couldn't read file: " + e.Message);
                    yield break;
                }

                if (read <= 0)
                    break;

                try
                {
                    asyncWrite = outStream.BeginWrite(buf, 0, read, null, null);
                }
                catch (Exception e)
                {
                    if (onError != null)
                        onError("Couldn't write file: " + e.Message);
                    yield break;
                }

                while (!asyncWrite.IsCompleted)
                    yield return null;

                try
                {
                    outStream.EndWrite(asyncWrite);
                }
                catch (Exception e)
                {
                    if (onError != null)
                        onError("Couldn't write file: " + e.Message);
                    yield break;
                }
            }

            inStream.Close();
            outStream.Close();

            yield return null;

            if (onSuccess != null)
                onSuccess();
        }

        protected static void Success(OnFileOpSuccess onSuccess, ApiFile apiFile, string message)
        {
            if (apiFile == null)
                apiFile = new ApiFile();

            Debug.Log("ApiFile " + apiFile.ToStringBrief() + ": Operation Succeeded!");
            if (onSuccess != null)
                onSuccess(apiFile, message);
        }

        protected static void Error(OnFileOpError onError, ApiFile apiFile, string error, string moreInfo = "")
        {
            if (apiFile == null)
                apiFile = new ApiFile();

            Debug.LogError("ApiFile " + apiFile.ToStringBrief() + ": Error: " + error + "\n" + moreInfo);
            if (onError != null)
                onError(apiFile, error);
        }

        protected static void Progress(OnFileOpProgress onProgress, ApiFile apiFile, string status, string subStatus = "", float pct = 0.0f)
        {
            if (apiFile == null)
                apiFile = new ApiFile();

            if (onProgress != null)
                onProgress(apiFile, status, subStatus, pct);
        }

        protected static bool CheckCancelled(FileOpCancelQuery cancelQuery, OnFileOpError onError, ApiFile apiFile)
        {
            if (apiFile == null)
            {
                Debug.LogError("apiFile was null");
                return true;
            }

            if (cancelQuery != null && cancelQuery(apiFile))
            {
                Debug.Log("ApiFile " + apiFile.ToStringBrief() + ": Operation cancelled");
                if (onError != null)
                    onError(apiFile, "Cancelled by user.");
                return true;
            }

            return false;
        }

        protected static void CleanupTempFiles(string subFolderName)
        {
            Instance.StartCoroutine(Instance.CleanupTempFilesInternal(subFolderName));
        }

        protected IEnumerator CleanupTempFilesInternal(string subFolderName)
        {
            if (!string.IsNullOrEmpty(subFolderName))
            {
                string folder = VRC.Tools.GetTempFolderPath(subFolderName);

                while (Directory.Exists(folder))
                {
                    try
                    {
                        if (Directory.Exists(folder))
                            Directory.Delete(folder, true);
                    }
                    catch (System.Exception)
                    {
                    }

                    yield return null;
                }
            }
        }

        private static void CheckInstance()
        {
            if (mInstance == null)
            {
                GameObject go = new GameObject("ApiFileHelper");
                mInstance = go.AddComponent<ApiFileHelper>();

                try
                {
                    GameObject.DontDestroyOnLoad(go);
                }
                catch
                {
                }
            }
        }

        private float GetServerProcessingWaitTimeoutForDataSize(int size)
        {
            float timeoutMultiplier = Mathf.Ceil((float)size / (float)SERVER_PROCESSING_WAIT_TIMEOUT_CHUNK_SIZE);
            return Mathf.Clamp(timeoutMultiplier * SERVER_PROCESSING_WAIT_TIMEOUT_PER_CHUNK_SIZE, SERVER_PROCESSING_WAIT_TIMEOUT_PER_CHUNK_SIZE, SERVER_PROCESSING_MAX_WAIT_TIMEOUT);
        }

        private bool uploadFileComponentValidateFileDesc(ApiFile apiFile, string filename, string md5Base64, long fileSize, ApiFile.Version.FileDescriptor fileDesc, Action<ApiFile> onSuccess, Action<string> onError)
        {
            if (fileDesc.status != ApiFile.Status.Waiting)
            {
                // nothing to do (might be a retry)
                Debug.Log("UploadFileComponent: (file record not in waiting status, done)");
                if (onSuccess != null)
                    onSuccess(apiFile);
                return false;
            }

            if (fileSize != fileDesc.sizeInBytes)
            {
                if (onError != null)
                    onError("File size does not match version descriptor");
                return false;
            }
            if (string.Compare(md5Base64, fileDesc.md5) != 0)
            {
                if (onError != null)
                    onError("File MD5 does not match version descriptor");
                return false;
            }

            // make sure file is right size
            long tempSize = 0;
            string errorStr = "";
            if (!VRC.Tools.GetFileSize(filename, out tempSize, out errorStr))
            {
                if (onError != null)
                    onError("Couldn't get file size");
                return false;
            }
            if (tempSize != fileSize)
            {
                if (onError != null)
                    onError("File size does not match input size");
                return false;
            }

            return true;
        }

        private IEnumerator uploadFileComponentDoSimpleUpload(ApiFile apiFile, ApiFile.Version.FileDescriptor.Type fileDescriptorType, string filename, string md5Base64, long fileSize, Action<ApiFile> onSuccess, Action<string> onError, Action<long, long> onProgess, FileOpCancelQuery cancelQuery)
        {
            OnFileOpError onCancelFunc = delegate (ApiFile file, string s)
            {
                if (onError != null)
                    onError(s);
            };

            string uploadUrl = "";
            while (true)
            {
                bool wait = true;
                string errorStr = "";
                bool worthRetry = false;

                apiFile.StartSimpleUpload(fileDescriptorType,
                    (c) =>
                    {
                        uploadUrl = (c as ApiDictContainer).ResponseDictionary["url"] as string;
                        wait = false;
                    },
                    (c) =>
                    {
                        errorStr = "Failed to start upload: " + c.Error;
                        wait = false;
                        if (c.Code == 400)
                            worthRetry = true;
                    });

                while (wait)
                {
                    if (CheckCancelled(cancelQuery, onCancelFunc, apiFile))
                    {
                        yield break;
                    }
                    yield return null;
                }

                if (!string.IsNullOrEmpty(errorStr))
                {
                    if (onError != null)
                        onError(errorStr);
                    if (!worthRetry)
                        yield break;
                }

                // delay to let write get through servers
                yield return new WaitForSecondsRealtime(kPostWriteDelay);

                if (!worthRetry)
                    break;
            }

            // PUT file
            {
                bool wait = true;
                string errorStr = "";

                VRC.HttpRequest req = ApiFile.PutSimpleFileToURL(uploadUrl, filename, GetMimeTypeFromExtension(Path.GetExtension(filename)), md5Base64,
                    delegate ()
                    {
                        wait = false;
                    },
                    delegate (string error)
                    {
                        errorStr = "Failed to upload file: " + error;
                        wait = false;
                    },
                    delegate (long uploaded, long length)
                    {
                        if (onProgess != null)
                            onProgess(uploaded, length);
                    }
                );

                while (wait)
                {
                    if (CheckCancelled(cancelQuery, onCancelFunc, apiFile))
                    {
                        if (req != null)
                            req.Abort();
                        yield break;
                    }
                    yield return null;
                }

                if (!string.IsNullOrEmpty(errorStr))
                {
                    if (onError != null)
                        onError(errorStr);
                    yield break;
                }
            }

            // finish upload
            while (true)
            {
                // delay to let write get through servers
                yield return new WaitForSecondsRealtime(kPostWriteDelay);

                bool wait = true;
                string errorStr = "";
                bool worthRetry = false;

                apiFile.FinishUpload(fileDescriptorType, null,
                    (c) =>
                    {
                        apiFile = c.Model as ApiFile;
                        wait = false;
                    },
                    (c) =>
                    {
                        errorStr = "Failed to finish upload: " + c.Error;
                        wait = false;
                        if (c.Code == 400)
                            worthRetry = false;
                    });

                while (wait)
                {
                    if (CheckCancelled(cancelQuery, onCancelFunc, apiFile))
                    {
                        yield break;
                    }
                    yield return null;
                }

                if (!string.IsNullOrEmpty(errorStr))
                {
                    if (onError != null)
                        onError(errorStr);
                    if (!worthRetry)
                        yield break;
                }

                // delay to let write get through servers
                yield return new WaitForSecondsRealtime(kPostWriteDelay);

                if (!worthRetry)
                    break;
            }

        }

        private IEnumerator uploadFileComponentDoMultipartUpload(ApiFile apiFile, ApiFile.Version.FileDescriptor.Type fileDescriptorType, string filename, string md5Base64, long fileSize, Action<ApiFile> onSuccess, Action<string> onError, Action<long, long> onProgess, FileOpCancelQuery cancelQuery)
        {
            FileStream fs = null;
            OnFileOpError onCancelFunc = delegate (ApiFile file, string s)
            {
                if (fs != null)
                    fs.Close();
                if (onError != null)
                    onError(s);
            };

            // query multipart upload status.
            // we might be resuming a previous upload
            ApiFile.UploadStatus uploadStatus = null;
            {
                while (true)
                {
                    bool wait = true;
                    string errorStr = "";
                    bool worthRetry = false;

                    apiFile.GetUploadStatus(apiFile.GetLatestVersionNumber(), fileDescriptorType,
                       (c) =>
                       {
                           uploadStatus = c.Model as ApiFile.UploadStatus;
                           wait = false;

                           Debug.Log("Found existing multipart upload status (next part = " + uploadStatus.nextPartNumber + ")");
                       },
                       (c) =>
                       {
                           errorStr = "Failed to query multipart upload status: " + c.Error;
                           wait = false;
                           if (c.Code == 400)
                               worthRetry = true;
                       });

                    while (wait)
                    {
                        if (CheckCancelled(cancelQuery, onCancelFunc, apiFile))
                        {
                            yield break;
                        }
                        yield return null;
                    }

                    if (!string.IsNullOrEmpty(errorStr))
                    {
                        if (onError != null)
                            onError(errorStr);
                        if (!worthRetry)
                            yield break;
                    }

                    if (!worthRetry)
                        break;
                }
            }

            // split file into chunks
            try
            {
                fs = File.OpenRead(filename);
            }
            catch (Exception e)
            {
                if (onError != null)
                    onError("Couldn't open file: " + e.Message);
                yield break;
            }

            byte[] buffer = new byte[kMultipartUploadChunkSize * 2];

            long totalBytesUploaded = 0;
            List<string> etags = new List<string>();
            if (uploadStatus != null)
                etags = uploadStatus.etags.ToList();

            int numParts = Mathf.Max(1, Mathf.FloorToInt((float)fs.Length / (float)kMultipartUploadChunkSize));
            for (int partNumber = 1; partNumber <= numParts; partNumber++)
            {
                // read chunk
                int bytesToRead = partNumber < numParts ? kMultipartUploadChunkSize : (int)(fs.Length - fs.Position);
                int bytesRead = 0;
                try
                {
                    bytesRead = fs.Read(buffer, 0, bytesToRead);
                }
                catch (Exception e)
                {
                    fs.Close();
                    if (onError != null)
                        onError("Couldn't read file: " + e.Message);
                    yield break;
                }

                if (bytesRead != bytesToRead)
                {
                    fs.Close();
                    if (onError != null)
                        onError("Couldn't read file: read incorrect number of bytes from stream");
                    yield break;
                }

                // check if this part has been upload already
                // NOTE: uploadStatus.nextPartNumber == number of parts already uploaded
                if (uploadStatus != null && partNumber <= uploadStatus.nextPartNumber)
                {
                    totalBytesUploaded += bytesRead;
                    continue;
                }

                // start upload
                string uploadUrl = "";

                while (true)
                {
                    bool wait = true;
                    string errorStr = "";
                    bool worthRetry = false;

                    apiFile.StartMultipartUpload(fileDescriptorType, partNumber,
                        (c) =>
                        {
                            uploadUrl = (c as ApiDictContainer).ResponseDictionary["url"] as string;
                            wait = false;
                        },
                        (c) =>
                        {
                            errorStr = "Failed to start part upload: " + c.Error;
                            wait = false;
                        });

                    while (wait)
                    {
                        if (CheckCancelled(cancelQuery, onCancelFunc, apiFile))
                        {
                            yield break;
                        }
                        yield return null;
                    }

                    if (!string.IsNullOrEmpty(errorStr))
                    {
                        fs.Close();
                        if (onError != null)
                            onError(errorStr);
                        if (!worthRetry)
                            yield break;
                    }

                    // delay to let write get through servers
                    yield return new WaitForSecondsRealtime(kPostWriteDelay);

                    if (!worthRetry)
                        break;
                }

                // PUT file part
                {
                    bool wait = true;
                    string errorStr = "";

                    VRC.HttpRequest req = ApiFile.PutMultipartDataToURL(uploadUrl, buffer, bytesRead, GetMimeTypeFromExtension(Path.GetExtension(filename)),
                        delegate (string etag)
                        {
                            if (!string.IsNullOrEmpty(etag))
                                etags.Add(etag);
                            totalBytesUploaded += bytesRead;
                            wait = false;
                        },
                        delegate (string error)
                        {
                            errorStr = "Failed to upload data: " + error;
                            wait = false;
                        },
                        delegate (long uploaded, long length)
                        {
                            if (onProgess != null)
                                onProgess(totalBytesUploaded + uploaded, fileSize);
                        }
                    );

                    while (wait)
                    {
                        if (CheckCancelled(cancelQuery, onCancelFunc, apiFile))
                        {
                            if (req != null)
                                req.Abort();
                            yield break;
                        }
                        yield return null;
                    }

                    if (!string.IsNullOrEmpty(errorStr))
                    {
                        fs.Close();
                        if (onError != null)
                            onError(errorStr);
                        yield break;
                    }
                }
            }

            // finish upload
            while (true)
            {
                // delay to let write get through servers
                yield return new WaitForSecondsRealtime(kPostWriteDelay);

                bool wait = true;
                string errorStr = "";
                bool worthRetry = false;

                apiFile.FinishUpload(fileDescriptorType, etags,
                    (c) =>
                    {
                        apiFile = c.Model as ApiFile;
                        wait = false;
                    },
                    (c) =>
                    {
                        errorStr = "Failed to finish upload: " + c.Error;
                        wait = false;
                        if (c.Code == 400)
                            worthRetry = true;
                    });

                while (wait)
                {
                    if (CheckCancelled(cancelQuery, onCancelFunc, apiFile))
                    {
                        yield break;
                    }
                    yield return null;
                }

                if (!string.IsNullOrEmpty(errorStr))
                {
                    fs.Close();
                    if (onError != null)
                        onError(errorStr);
                    if (!worthRetry)
                        yield break;
                }

                // delay to let write get through servers
                yield return new WaitForSecondsRealtime(kPostWriteDelay);

                if (!worthRetry)
                    break;
            }

            fs.Close();
        }

        private IEnumerator uploadFileComponentVerifyRecord(ApiFile apiFile, ApiFile.Version.FileDescriptor.Type fileDescriptorType, string filename, string md5Base64, long fileSize, ApiFile.Version.FileDescriptor fileDesc, Action<ApiFile> onSuccess, Action<string> onError, Action<long, long> onProgess, FileOpCancelQuery cancelQuery)
        {
            OnFileOpError onCancelFunc = delegate (ApiFile file, string s)
            {
                if (onError != null)
                    onError(s);
            };

            float initialStartTime = Time.realtimeSinceStartup;
            float startTime = initialStartTime;
            float timeout = GetServerProcessingWaitTimeoutForDataSize(fileDesc.sizeInBytes);
            float waitDelay = SERVER_PROCESSING_INITIAL_RETRY_TIME;
            float maxDelay = SERVER_PROCESSING_MAX_RETRY_TIME;

            while (true)
            {
                if (apiFile == null)
                {
                    if (onError != null)
                        onError("ApiFile is null");
                    yield break;
                }

                var desc = apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), fileDescriptorType);
                if (desc == null)
                {
                    if (onError != null)
                        onError("File descriptor is null ('" + fileDescriptorType + "')");
                    yield break;
                }

                if (desc.status != ApiFile.Status.Waiting)
                {
                    // upload completed or is processing
                    break;
                }

                // wait for next poll 
                while (Time.realtimeSinceStartup - startTime < waitDelay)
                {
                    if (CheckCancelled(cancelQuery, onCancelFunc, apiFile))
                    {
                        yield break;
                    }

                    if (Time.realtimeSinceStartup - initialStartTime > timeout)
                    {
                        if (onError != null)
                            onError("Couldn't verify upload status: Timed out wait for server processing");
                        yield break;
                    }

                    yield return null;
                }


                while (true)
                {
                    bool wait = true;
                    string errorStr = "";
                    bool worthRetry = false;

                    apiFile.Refresh(
                        (c) =>
                        {
                            wait = false;
                        },
                        (c) =>
                        {
                            errorStr = "Couldn't verify upload status: " + c.Error;
                            wait = false;
                            if (c.Code == 400)
                                worthRetry = true;
                        });

                    while (wait)
                    {
                        if (CheckCancelled(cancelQuery, onCancelFunc, apiFile))
                        {
                            yield break;
                        }

                        yield return null;
                    }

                    if (!string.IsNullOrEmpty(errorStr))
                    {
                        if (onError != null)
                            onError(errorStr);
                        if (!worthRetry)
                            yield break;
                    }

                    if (!worthRetry)
                        break;
                }

                waitDelay = Mathf.Min(waitDelay * 2, maxDelay);
                startTime = Time.realtimeSinceStartup;
            }

            if (onSuccess != null)
                onSuccess(apiFile);
        }

        private IEnumerator UploadFileComponentInternal(ApiFile apiFile, ApiFile.Version.FileDescriptor.Type fileDescriptorType, string filename, string md5Base64, long fileSize, Action<ApiFile> onSuccess, Action<string> onError, Action<long, long> onProgess, FileOpCancelQuery cancelQuery)
        {
            Debug.Log("UploadFileComponent: " + fileDescriptorType + " (" + apiFile.id + "): " + filename);
            ApiFile.Version.FileDescriptor fileDesc = apiFile.GetFileDescriptor(apiFile.GetLatestVersionNumber(), fileDescriptorType);

            if (!uploadFileComponentValidateFileDesc(apiFile, filename, md5Base64, fileSize, fileDesc, onSuccess, onError))
                yield break;

            switch (fileDesc.category)
            {
                case ApiFile.Category.Simple:
                    yield return uploadFileComponentDoSimpleUpload(apiFile, fileDescriptorType, filename, md5Base64, fileSize, onSuccess, onError, onProgess, cancelQuery);
                    break;
                case ApiFile.Category.Multipart:
                    yield return uploadFileComponentDoMultipartUpload(apiFile, fileDescriptorType, filename, md5Base64, fileSize, onSuccess, onError, onProgess, cancelQuery);
                    break;
                default:
                    if (onError != null)
                        onError("Unknown file category type: " + fileDesc.category);
                    yield break;
            }

            yield return uploadFileComponentVerifyRecord(apiFile, fileDescriptorType, filename, md5Base64, fileSize, fileDesc, onSuccess, onError, onProgess, cancelQuery);
        }
    }
}

#endif
